前言
许久没能给blog清灰,不知不觉已经到了2023年,虽说依然可以用“做的东西大多是项目的,无法公开”来给自己开脱,但多少还是有些惭愧的。
近期我感觉到如果只是学习Shader相关的内容,作为TA的话技能树就显得有些窄了,因此又重新拾起大学时期曾经学过一点的Houdini,开始做一些更加实用的效果与功能。而这一次我的学习目标是“学习Houdini中烘焙贴图的方法”,之前曾看到过将Houdini中海洋作为序列帧烘焙到引擎中的方案,这次便是要着手亲自实现。
*使用Houdini版本为19.0.383
烘焙波形
Houdini中有着强大的Ocean系列节点,也被广泛用于影视项目中。
将Grid与Ocean Spectrum输入Oceane Valuate即可生成比较真实的波形(注意Grid的大小要与Ocean Spectrum中的匹配,顶点数也要足够,这里为了之后烘焙,便设置为256x256行列)
那么为了将每个顶点的位移存储到tga或png格式的贴图中,我们要将其的范围映射到[0, 1],那么就该写vex了(其实用Attribute Remap节点大概也可以,但可能是因为我终究是程序出身,还是更喜欢自己写代码控制,有一种莫名的安心感233)
对vex不了解的读者可以阅读学习Joy of Vex
代码比较简单,只是将每个顶点的位移记录为一个名为displace的Attribute。
观察过计算出的Attribute后发现其值大多不大于2且不小于-2,因此先设置原值的映射范围为[-2, 2],当然根据波形的设置这个值可能会不一样,也当然被映射的范围越大,存储的精度便越低。
然后直接使用Labs Maps Baker对displace进行烘焙,因为我们之后要将其存储为Flipbook,因此要计算一下每一张序列帧的大小,我这里打算最后成图为2048x2048分辨率,存储8x8共64帧(注意也要将Ocean Spectrum中的Loop Period设置为64/24(fps)即2.666666),每帧 256x256分辨率,因此设置如下:
按下Render后烘焙结果:
然后切换到Img层级,新建img,在img中新建File节点读取序列帧后连接Mosaic节点(注意,只有在下面的timeline为第一帧时才能预览到Flipbook,谜之设计),即可创建Flipbook,直接在Composite View这边右键Save Frame即可。
那么导出Plane的模型,该转到Unity中进行波形的还原了
还原波形
到了Unity中将DisplaceMap的srgb勾选取消。然后给海面Plane写一个定制的Shader
在Shader中可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| half2 FlipBook(half2 uv, uint horizontalAmount, uint verticalAmount, uint num) { num %= (horizontalAmount * verticalAmount); int row = num / horizontalAmount; int column = num - row * horizontalAmount;
half2 newUv = frac(uv) + half2(column, verticalAmount - row - 1); newUv.x /= horizontalAmount; newUv.y /= verticalAmount; return newUv; }
v2f vert(a2v v) { v2f o;
float2 currentUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed); float2 nextUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed + 1);
float3 currentDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, currentUV , 0).xyz; float3 nextDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, nextUV , 0).xyz;
currentDisplace = currentDisplace * 4 - 2; nextDisplace = nextDisplace * 4 - 2; float3 displace = lerp(currentDisplace, nextDisplace, frac(_Time.y * _Speed)); v.positionOS.xyz += displace;
VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz); o.positionCS = positionInputs.positionCS; o.positionWS = positionInputs.positionWS;
}
|
可见是一个非常明了的用uv采样Flipbook然后直接将值应用的没什么特别操作的代码。
然后就发现了问题:
我这波形怎么这么圆呢?
这个时候我百思不得其解,偶然之间把它翻转过来看了下,发现“背面”的波形好像是“对”的:
其实这个问题是因为“uv反了”,我们在代码中加一个简单的uv旋转,使其旋转180°:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| half2 RotateByCenter(half2 uv, half2 center, float rotateAngle) { half2 transUV = uv - center; rotateAngle = rotateAngle / 180 * 3.1415926;
transUV = half2(transUV.x * cos(rotateAngle) - transUV.y * sin(rotateAngle), transUV.x * sin(rotateAngle) + transUV.y * cos(rotateAngle));
return transUV + center; }
v2f vert(a2v v) { v2f o; v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180);
}
|
即可获得正确的波形。
为什么是这样呢,我们先把问题简化,假设uv为一维而偏移方向为二维,如果uv的值“反转”,情况就会是这样:
那么明显可见,这种情况下肉眼所见的波形肯定是不同的,而将平面翻转过来它的背面波形看上去似是而非也很好理解。
至于为什么uv会反,大概是因为Houdini的这个Labs Maps Baker似乎并不依赖于uv进行烘焙,就算不进行展uv,一样能够进行相同的烘焙。大概是直接取用了坐标来转换成uv?然后用节点展的uv恰好与坐标转换出的uv“相反”了。
那么当我想多复制几个海面,让海面更辽阔时,又发现它们之间并不会完全相连,它们之间会出现这种缝隙
但其实每一帧位移图都是四方连续的,这种情况的发生是由于序列帧本身储存为贴图时并没有左右连续(因为是Flipbook),反倒是与另一帧进行了双线性插值,导致出现问题。
因此在采样前我们要对uv进行一点点偏移,仅偏移半个像素大小,使每个顶点uv移到所采样的贴图的像素中心即可即可。
1 2 3 4 5 6
| v2f vert(a2v v) { v2f o; v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180); v.uv += _DisplaceMap_TexelSize.xy * _RowAndColumn.x * 0.5; }
|
这样便能消除接缝
法线与切线
那么下一步就是让这个海能够正常渲染了,为此我们还需要它的法线,如果要在此基础上再加一个Detail Normal的话则还需要切线,让我们回到Houdini。
在演算完波形后计算Point法线以及切线,并将它们映射到[0, 1]。
要注意切线必须是Mikkt算法,以求与Unity相同。
烘焙与Displace相同,同样是作为Attribute烘焙
序列帧烘出来是这样
总之也跟之前一样生成Flipbook
记得在Unity中同样将它们的SRGB勾选去掉。
因此在顶点着色器中可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| v2f vert(a2v v) { v2f o; float2 posToUV = v.positionOS.xz / _PlaneScale + 0.5 + _NormalMap_TexelSize.xy * _RowAndColumn.x; v.uv = RotateByCenter(v.uv, half2(0.5, 0.5), 180); v.uv += _DisplaceMap_TexelSize.xy * _RowAndColumn.x * 0.5;
float2 currentUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed); float2 nextUV = FlipBook(v.uv, _RowAndColumn.x, _RowAndColumn.y, _Time.y * _Speed + 1);
float2 useCurrentPosUV = currentUV; float2 useNextPosUV = nextUV;
float3 currentDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, useCurrentPosUV , 0).xyz; float3 nextDisplace = SAMPLE_TEXTURE2D_LOD(_DisplaceMap, sampler_DisplaceMap, useNextPosUV , 0).xyz;
currentDisplace = currentDisplace * 4 - 2; nextDisplace = nextDisplace * 4 - 2; float3 displace = lerp(currentDisplace, nextDisplace, frac(_Time.y * _Speed)); v.positionOS.xyz += displace;
VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz); o.positionCS = positionInputs.positionCS; o.positionWS = positionInputs.positionWS;
float3 currentNormalOS = SAMPLE_TEXTURE2D_LOD(_NormalMap, sampler_NormalMap, useCurrentPosUV, 0).xyz * 2 - 1; float3 nextNormalOS = SAMPLE_TEXTURE2D_LOD(_NormalMap, sampler_NormalMap, useNextPosUV, 0) * 2 - 1; float3 normalOS= lerp(currentNormalOS, nextNormalOS, frac(_Time.y * _Speed)); normalOS = normalize(normalOS);
float3 currentTangnetOS = SAMPLE_TEXTURE2D_LOD(_TangentMap, sampler_TangentMap, useCurrentPosUV, 0).xyz * 2 - 1; float3 nextTangentOS = SAMPLE_TEXTURE2D_LOD(_TangentMap, sampler_TangentMap, useNextPosUV, 0) * 2 - 1; float3 tangentOS= lerp(currentTangnetOS, nextTangentOS, frac(_Time.y * _Speed)); tangentOS = normalize(tangentOS);
VertexNormalInputs normalInputs = GetVertexNormalInputs(normalOS, half4(tangentOS, 1)); o.normalWS = normalInputs.normalWS; o.tangentWS = normalInputs.tangentWS; o.uv = v.uv; return o; }
|
在片元着色器中简单地算一个着色先:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| half4 frag(v2f i) : SV_Target {
float3 detailNormal = UnpackNormalScale(SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailNormalMap, TRANSFORM_TEX(i.uv, _DetailNormalMap) + _Time.y * _DetailNormalMap_ST.zw * _FlowSpeed), _DetailNormalScale);
float3x3 TBN = CreateTangentToWorld(normalize(i.normalWS), normalize(i.tangentWS.xyz), -1); float3 detailNormalWS = normalize(TransformTangentToWorld(detailNormal, TBN));
float3 normalWS = detailNormalWS;
float lambert = dot(normalWS, normalize(GetMainLight().direction)); float3 diffuse = lerp(_DarkColor, _BrightColor, lambert * 0.5 + 0.5);
float3 viewDirWS = normalize(GetWorldSpaceViewDir(i.positionWS)); float3 halfDir = normalize(viewDirWS + GetMainLight().direction); float specular = pow(saturate(dot(halfDir, normalWS)), 1000);
return diffuse.rgbr + specular * 5; }
|
粗略的效果大概是这样,接下来就是着色算法的表演时间了,VAT相关的实现大概就到这里。
那么修改亿点点着色算法:
我们就获得了一片美丽的海洋。
由于着色算法并不是本文重点,因此就不展开了,不然又要长篇大论一番。
总结
这个方案在各个海洋方案中大概也算得上是相对亲民的,美术也比较轻松(不需要调波形),如果是直接在顶点着色器中计算波形,那美术恐怕会面对对他们来说如鬼画符般的“振幅”“波长”“频率”等参数,这对美术而言可以说是无法接受的。
如果要说有什么优化空间,大概是法线和切线能够存储在同一贴图中,因为被标准化后的三维向量能够通过一些算法压缩到二维,且有较高的准确率。那么便可以实现法线信息存储在RG通道,而切线信息存储在BA通道,可以节省一次贴图采样的开销,还是不错的。
向量压缩算法可参考:https://aras-p.info/texts/CompactNormalStorage.html#method04spheremap
以及距离较远时可以直接切换LOD到顶点较少(不需要Displace)只有法线贴图的海面。
那么本文就到此结束,感谢阅读,若你能从此文中有所收获,便再好不过了。
感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。